昨天 D08 的 EXPLAIN ANALYZE 印出一條有點違反直覺的線:
row_groups_pruned_statistics=5 total → 5 matched
bytes_scanned=98.53 M
scan_efficiency_ratio=99.95% (98.53 M/98.57 M)
翻譯過來就是:昨天那句 ORDER BY id, v, s 一個 row group 都沒剪掉、幾乎讀了整個檔案。可是 Parquet 不是號稱「只讀該讀的位元組」嗎?
要看懂這條線印的是什麼、什麼時候會不一樣,得先知道 Parquet 檔案到底長什麼樣。
CSV 打開就是每列一行、人眼看得懂;Parquet 打開就是 binary,什麼都看不見。
今天就把它一層一層剖開。
四個問題連問:
ORDER BY 剪不掉,換一個 WHERE 就能剪掉 98% 的 I/O?
Parquet 是欄式檔案格式,從外往裡看是這樣:
Parquet File
├── Row Group 0 ← 一組列的水平切片
│ ├── Column Chunk (欄 1) ← 這一組列的某一欄
│ │ ├── Dictionary Page (可選,字典編碼用)
│ │ └── Data Page 1..N (實際壓縮過的資料頁)
│ ├── Column Chunk (欄 2)
│ ├── Column Chunk (欄 3)
│ └── ...
├── Row Group 1
├── Row Group 2
├── ...
└── Footer ← metadata 住這裡:schema、每 RG 每欄的
min/max、null count、page offsets
拆兩層講
Row Group 是一組「水平切開」的列,DataFusion 55.0 寫檔預設一個 RG 是 1,048,576 列(2²⁰);一份 500 萬列的檔就會被切成 5 個 RG。RG 之間互相不重疊、也不共用資料,是引擎剪枝的最小整段單位。
Column Chunk 是同一個 RG 裡「垂直切開」的一個欄,一個 4 欄的檔在每個 RG 裡有 4 個 column chunk,內部再切成一頁一頁的 Data Page。之所以要這樣切,就是欄式的核心命題——同一欄的資料連續放在一起、不同欄分開放。SELECT g 只讀 g 那一段位元組,其他三欄連 disk seek 都不用付。
Parquet 也支援 nested schema(struct、list、map),data page 裡會多兩個小整數 repetition level 跟 definition level(來自 Google 的 Dremel 論文)讓引擎知道每個值屬於哪一層巢狀。這篇用的 sortbench.parquet 都是平的 primitive 欄,這件事之後有機會再挖。
打開 Parquet 檔案的第一步不是從頭開始讀——是先跳到檔案最後:
[ ... 資料頁 ... ][ Footer ][ Footer length (4 bytes) ][ Magic "PAR1" (4 bytes) ]
檔案最後 8 個位元組是 4 bytes 的 footer 長度 + 4 bytes 的 magic PAR1。讀檔前先發一個 range request 拿最後這 8 KB 左右,就能同時驗證這是 parquet 檔、知道 footer 多大、順便把 footer 整份讀進來;然後才根據 footer 記的 page offset 回頭去讀真正要的資料頁。
為什麼放最後不放開頭?兩個理由:
append-only 寫入友善。寫檔的時候,metadata 是最後才能算完整的——RG 的 offset、每欄的 min/max、壓縮後大小,這些東西都要等資料寫完才知道。把 metadata 放最後,寫檔就是一路 append 資料頁、寫完最後補上 footer 就結束,不需要回頭改開頭的欄位。放開頭的話得先預留一段空間、寫完再回填,複雜一階。
物件儲存友善。Parquet 大量部署在 S3 / GCS / R2 這種物件儲存上,range GET 是最基本的 API。整個檔案 100 MB,只想看 metadata 決定要不要讀?range GET 最後 8 KB 就夠了。metadata 沒告訴你要繼續讀?連資料頁的位元組都不用付流量。
拿昨天 D08 造的那份 sortbench.parquet(98.57 MB,500 萬列,4 欄 id, g, v, s)開來看實際長怎樣。DataFusion 內建 parquet_metadata() 函式可以印出每個 RG 每個 column chunk 的細節:
SELECT row_group_id, path_in_schema, type,
row_group_num_rows, stats_min, stats_max,
total_compressed_size, total_uncompressed_size
FROM parquet_metadata('sortbench.parquet');
把每個 RG 的 id 這欄挑出來看:
| RG | 列數 | id min |
id max |
這個 RG 壓縮後大小 |
|---|---|---|---|---|
| 0 | 1,048,576 | 1 | 1,048,576 | ~20.7 MB |
| 1 | 1,048,576 | 1,048,577 | 2,097,152 | ~20.7 MB |
| 2 | 1,048,576 | 2,097,153 | 3,145,728 | ~20.7 MB |
| 3 | 1,048,576 | 3,145,729 | 4,194,304 | ~20.7 MB |
| 4 | 805,696 | 4,194,305 | 5,000,000 | ~15.9 MB |
一眼可以看到幾件事:
id 是遞增寫入的,每個 RG 的 min/max 不重疊——這是後面能剪枝的關鍵g 每個 RG 的 min/max 都是 0..7(因為它 mod 8),完全無法用 min/max 剪枝g 這欄未壓縮約 390 KB、壓縮後只有 2.6 KB(壓縮比 ~150 倍),因為 RLE (Run-Length Encoding) + dictionary 對 8 個相異值狂殺;s 是 md5 隨機字串,壓縮比只有 ~2 倍,占了每個 RG 大部分容量回到開頭那個「為什麼 D08 什麼都沒剪掉」的疑問。答案在引擎能不能拿 footer 的 min/max 去 match query 的 predicate。
用同一份 sortbench.parquet 跑兩個對照組:
SET datafusion.execution.target_partitions = 1;
-- 用 id 當 WHERE:id 的 min/max 遞增不重疊,能剪
EXPLAIN ANALYZE
SELECT COUNT(*) FROM 'sortbench.parquet' WHERE id > 4000000;
-- 用 g 當 WHERE:g 的 min/max 每個 RG 都 0..7,剪不動
EXPLAIN ANALYZE
SELECT COUNT(*) FROM 'sortbench.parquet' WHERE g = 5;
DataSourceExec 那一行的關鍵指標比一比:
| 指標 | WHERE id > 4000000 |
WHERE g = 5 |
|---|---|---|
row_groups_pruned_statistics |
5 → 2 matched(剪掉 3 個 RG) | 5 → 5 matched(沒剪任何 RG) |
page_index_pages_pruned |
52 → 10 matched | 248 → 248 matched |
bytes_scanned |
1.41 MB | 12.67 KB* |
scan_efficiency_ratio |
1.43% | 0.01%* |
id > 4000000 這條:footer 記 RG 0/1/2 的 max 分別是 1M / 2M / 3M,都 ≤ 4M,直接整段跳掉;RG 3(max 4,194,304,部分符合)跟 RG 4(min 4,194,305,全部符合)留下來繼續讀。5 個 RG 剪掉 3 個、剩下的 2 個內部再靠 page index 從 52 頁篩到 10 頁,最後只讀 1.41 MB。這就是 Parquet「只讀該讀的位元組」的樣子。
g = 5 這條的 bytes_scanned 更小(12 KB),看起來反而更好?別被騙——12 KB 只是因為 SELECT COUNT(*) 只需要 g 那一欄,而 g 全檔壓縮後總共就那麼多。剪枝完全沒發生(5 → 5、248 → 248),欄式讓「全讀」也很便宜而已。換成 SELECT * FROM ... WHERE g = 5,就會把整個 5 × 20 MB 全部拉進來。
回到 D08 那句 SELECT id, g, v, s FROM ... ORDER BY id, v, s:
WHERE → 任何 predicate pruning 都不會發生 → 5 個 RG 全留bytes_scanned=98.53 M,幾乎整個檔案不是 Parquet 沒 work,是那句 query 本來就沒東西可剪。
一句話:Parquet 的「只讀該讀的位元組」是有條件的——條件是 query 的 projection 對得到 column chunk、predicate 對得到 footer 記的 min/max。兩個都對不到的時候,Parquet 就退化成一個「按欄排列的壓縮檔」,沒有魔法。
昨天 D08 的 bytes_scanned 幾乎全滿不是格式的錯,是那句 SQL 讓 Parquet 沒得幫忙。把 footer 看清楚之後就知道,換一個 predicate、或換一種資料排布(例如把 id 拿去當第一個 sort key 寫檔),同一份 Parquet 能省下 98% 的 I/O。
留一個沒有標準答案的問題:footer 記的 min/max 是「這個 RG 值域」的極粗近似,只能告訴你「可能有」不能告訴你「一定有」。要精確就得靠 bloom filter 或 page index,那是額外的 metadata。這兩層 metadata 該記到多細,才叫剛好?記多了 footer 會胖到 range GET 一次讀不完、記少了引擎又要多讀資料。
明天 D10 換一個尺度看資料,一張 Iceberg 表由多個 Parquet 檔組成,「該讀哪些檔」由更上一層的 catalog → metadata → manifest 決定。今天講的 footer 是檔案內的剪枝,明天講的 manifest 是檔案間的剪枝,兩層是遞迴同構的。
那就明天見~